feat(types): declare the CEL envelope on visible / hidden / disabled as one shared ExpressionWire (objectui#7530, ruled A) - #7726
Conversation
…bled` as one shared `ExpressionWire`
`BaseSchema.visible` / `.hidden` / `.disabled` go from `boolean | string` to
`boolean | ExpressionWire` on both faces, where `ExpressionWire` is
`string | { dialect?: string; source: string }` -- the string-or-envelope union
`FormField.visibleWhen` already carried, and the exact accept set of core's
`toPredicateInput` / `hasDeclaredPredicate`. The Zod mirror's
`z.union([z.boolean(), z.string()])` becomes
`z.union([z.boolean(), ExpressionWireSchema])` on all three keys.
The envelope was already an evaluated input on every key: `visible` is handed
to `evaluateCondition` as soon as it is declared, `hidden` / `disabled` ask
`hasDeclaredPredicate` first, and both routes honour `{ dialect: 'cel', source }`
(pinned through a `Record` cast in the hidden/disabled declared-gate tests).
Measured before this change on f96a781: `BaseSchema.safeParse({ type, hidden:
{ dialect: 'cel', source: 'true' } })` returned `success: false` with
`invalid_union` at path `hidden` while the identical envelope parsed on
`FormField.visibleWhen` one file over. Declared did not match enforced, on all
three keys.
One wire type, not three: `ExpressionWireSchema` is hoisted out of
`zod/form.zod.ts` (module-private there) into `zod/expression.zod.ts` and
imported by `base.zod.ts` and `form.zod.ts`; a TS twin `ExpressionWire` lives in
`expression.ts`. Both are exported. Reuse is pinned by reference, not by shape,
so a faithful copy cannot pass as the shared type. No per-key branch in the
shared evaluator (the rejected alternative).
Pins: a new types pin holds all three keys to `boolean | ExpressionWire |
undefined` invariantly, the TS/zod twins equal, the object arm identical by
reference on every key and on the form legs, and the envelope parsing (with and
without `dialect`) beside the refusals; a new react pin renders the `${...}`
string, the `cel` envelope and the dialect-less envelope of one predicate on
each key, in both polarities, and asserts one verdict per key. The hidden
declared-gate envelope pin drops its `Record` cast. Six pins that asserted the
old union exactly widen with it.
Docs: the three schema-reference rows name the envelope, and the component
pages' inherited `disabled` / `visible` rows spell the same flat union -- the
6347 pin reads the mirror through the nested union and the 7239 pin holds the
14 inherited `disabled` pages to that spelling.
Ruled by the maintainer on 2026-09-04, objectui#7530 option A.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s
…ssue-7530-predicate-envelope-declared
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Merged
Labels read back after the write: Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Provenance for clearing Generated by Claude Code |
…ssue-7530-predicate-envelope-declared
… every row this branch touched
`{ dialect?, source }` is not TypeScript: `box.mdx` fences its schema block
`ts`, `check:doc-snippets` compiles it against the built `@object-ui/types`
dist, and CI reported TS7008 on `dialect` and `source` at `box.mdx:50` (the one
touched row inside a `ts` fence; the other 26 pages fence `plaintext` /
`text`, and the schema-reference rows are table cells). One spelling for every
row this branch touched rather than one per fence language:
`{ dialect?: string; source: string }` -- valid inside a `ts` fence and a
faithful reading of the mirror's object arm.
29 component rows on 27 pages and the three `schema-reference.md` rows move to
it. `declaredTypeText` in `button-group-doc-surface-6347.test.ts` now prints
the object arm with each member's type (`?` on the optional wrapper), and the
`component-docs-disabled-inherited-7239.test.ts` literal follows; both pins'
row regexes now run to the `;` that ends the row instead of the first `;`,
which the inline object type carries between its members.
Nothing else moves; the untouched `condition` row keeps its own spelling.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s
|
Patch round 1 (PM seat, 2026-09-05T12:34Z) — CI Cause, measured. Of the 32 rows this branch touched, exactly one sits inside a Fix, one commit Merged Gates on head
Not re-run this round (the patch is docs plus two types tests, no package source): the Generated by Claude Code |
|
Provenance for clearing Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #7530
Ruling executed — option A (maintainer 2026-09-04, decision batch #28; director comment 5536048209)
visible,hiddenanddisabledonBaseSchemadeclareboolean | ExpressionWireon both faces, whereExpressionWireis the EXISTING string-or-envelope unionvisibleWhenalready used — hoisted into a shared module and reused by reference. No second envelope type; no per-key branch inhasDeclaredPredicate(packages/core/src/evaluator/declaredPredicate.tsis untouched).The three keys, before → after (both faces)
base.ts)base.zod.ts)visibleboolean | string(:281)boolean | ExpressionWire(:292)z.union([z.boolean(), z.string()])(:158)z.union([z.boolean(), ExpressionWireSchema])(:167)hiddenboolean | string(:359)boolean | ExpressionWire(:376)z.union([z.boolean(), z.string()])(:188)z.union([z.boolean(), ExpressionWireSchema])(:200)disabledboolean | string(:385)boolean | ExpressionWire(:412)z.union([z.boolean(), z.string()])(:203)z.union([z.boolean(), ExpressionWireSchema])(:219)ExpressionWireisstring | { dialect?: string; source: string }— in words: a string, or an object with an optional stringdialectand a required stringsource. (Generic-shaped spellings such as the zod input helper are written out in words in this body because GitHub strips tag-shaped fragments on save.)The hoist — one wire type across the platform
packages/types/src/zod/expression.zod.ts(new):ExpressionWireSchema, moved out ofzod/form.zod.tswhere it was a module-private const;base.zod.tsandform.zod.tsimport it.form.zod.tsis touched ONLY at its top region (the const and its import line) — finding(types): 4 more genuinely-read undeclared keys the #6150 census could not see, plus one declared-but-dead key — all on the same 8 renderers #6938's dev edits theCheckboxSchemablock of the same file this batch.packages/types/src/expression.ts(new): the TS twinExpressionWire;base.tsimports it.ExpressionWirefrom the main entry,ExpressionWireSchemafrom@object-ui/types/zod. The census inzod-mirror-parity.test.tsgains anEXCLUSIONSrow for the new const (a union, no.shape).BaseSchema.shape.visible/.hidden/.disabledunwrap to a union whose object arm ISExpressionWireSchema(identity,toBe), and so doFormFieldSchema.visibleWhen/visibleOn/readonlyWhen/requiredWhenandSelectOptionSchema.visibleWhen. A faithful copy passes every value comparison and is still the second envelope type the ruling forbids.PM mechanism assumption — measured
hasDeclaredPredicate→toPredicateInput(packages/core/src/evaluator/predicateInput.ts) accepts an object with a stringsourceand ANYdialect(absent or any string): only'cel'keeps its envelope on the@objectstack/formulaengine; every other dialect is unwrapped onto the legacy template path; an object without a stringsourceis "no predicate".ExpressionWireSchema's object arm is{ dialect: z.string().optional(), source: z.string() }— the same set. So the runtime accepts exactly the wire shape, not only{ dialect: 'cel', source }; the declaration is neither wider nor narrower than the runtime. The dialect-less envelope is pinned on both faces (types: parses; react: renders identically).Renderer identity — measured
packages/react/src/__tests__/SchemaRenderer.predicateEnvelopeDeclared.test.tsx(new; no casts, every schema typedBaseSchema): one predicate in three spellings — the${...}template string,{ dialect: 'cel', source: 'data.status == "draft"' }, and the dialect-less{ source: '${...}' }— on each of the three keys, in both polarities (statusis draft → holds;publishedis false → fails). Verdicts read side by side: visible/holds → rendered, visible/fails → not rendered; hidden/holds → not rendered, hidden/fails → rendered; disabled/holds →disabledforwarded astrue, disabled/fails → nothing forwarded. One verdict per key and polarity across all three spellings, and the two polarities differ — so this is not three spellings agreeing on the evaluator's fail-soft constant.SchemaRenderer.hiddenDeclaredGate.test.tsx:188(the envelope pin) now runs throughrenderDeclaredNode; theRecordcast is gone.ADR-0089 reading (objectstack
docs/adr/0089-unify-visibility-predicate-naming.md)Read in full for a string-only statement on predicate keys: there is none. D1 gives
visibleWhenthe spec'sExpressionInputSchemavalue, which IS the envelope. D4's carve-out — "the booleanvisible(Tab on/off) is a different type and concept and is explicitly out of scope" — is aboutpackages/spec/src/ui/view.zod.ts'svisible: z.boolean()and, as #7455's triage established, does not bind objectui'sBaseSchema. No conflict; nothing narrowed.Docs
content/docs/api/schema-reference.md: the three rows readboolean \| string \| { dialect?, source }and name the envelope (the spelling the existingconditionrow already uses).button-group-doc-surface-6347.test.tsreads the zod mirror and compares it tobutton-group.mdx's inheriteddisabledrow, andcomponent-docs-disabled-inherited-7239.test.tsholds the other 13 inherited pages to that spelling — the first fullpackages/types/run after the widening failed exactly those two 6347 cases (boolean | unionvsboolean | string).declaredTypeTextin the 6347 pin now flattens the nested union and spells the object arm; the 7239 literal moves toboolean | string | { dialect?, source }; 27 rows on 25 pages (14 inheriteddisabled, 13 inheritedvisible) plusspan.mdx/page.mdx(doc-local names whose shippedTextSpanSchema/PageNodeSchemaextendBaseSchema) carry the new spelling. Left as is:layout/semantic.mdx:63(SemanticSchemais doc-local, no shipped interface to check inheritance against) and the twocontent/docs/corerows on other surfaces — both recorded in the finding below.Changeset / tier
.changeset/7530-predicate-envelope-declared.md—@object-ui/types: minor(accept set widens by an already-shipping shape; two new exported names).CONTRACT_REVIEW_TIER:needs:contract-reviewapplied; PR stays draft. Noskip-changeset— a phantom label in this repo.Red-first and reverse verification
f96a781tree with the new types pin in place: vitest 10 failed / 21 passed (six envelope-parse cases, three by-reference identities, the form-leg identity);tsc -p tsconfig.test.json: TS2344 on the three key assertions and TS2322 on the four envelope fixtures, while the twin-parity and wire-shape assertions already compiled clean.9ae4286;base.ts+base.zod.tschecked out from theBASEblobf96a781, trap-restored withgit checkout HEAD --): vitest 9 failed / 22 passed — the form-leg identity stays green becauseform.zod.tswas not reverted, which is the hoist being real;tsc17 errors across the new pin and the six widened pins; restore proven bygit diff HEAD= 0 lines and blob-hash equality (base.ts32f9784f,base.zod.ts9a58c0d7); green leg after restore 31/31..d.ts:@object-ui/typeswas rebuilt before@object-ui/react type-check, and the new react pin (envelope literals inBaseSchema-typed schemas) compiles clean — the pre-widening.d.tsrejects those literals with TS2322, so a stale dist would have turned it red.Gates (exit codes captured before any pipe; all at HEAD
9ae4286, single commit onf96a781)pnpm --filter '@object-ui/react^...' build(baseline closure, before edits)pnpm --filter @object-ui/types build(after edits)check-dist-completenesspnpm exec vitest run --maxWorkers=2 packages/types/pnpm exec vitest run --maxWorkers=2 packages/react/pnpm --filter @object-ui/types type-checktsc --noEmit, examples,tsconfig.test.jsonpnpm --filter @object-ui/core type-checkpnpm --filter @object-ui/react type-checkpnpm --filter @object-ui/types lintpnpm --filter @object-ui/react lintpnpm check:spec-symbolspnpm check:control-bytespnpm check:doc-types/pnpm check:doc-fences/pnpm docs:check-linkspnpm check:unreferenced-sourcesnode scripts/check-changeset-presence.mjs/check-changeset-fixed.mjs/check-changeset-no-major.mjspnpm check:doc-snippetsts/tsxfence (table cells andplaintextblocks only)Declared narrowing: consumer packages beyond types / core / react were not type-checked locally (the full downstream sweep is 38 packages); CI's full
type-checkis the measurement. Consumers read the three keys throughevaluateCondition/toPredicateInput/hasDeclaredVisibilityGate, all of which already accept the object arm.Out of scope, filed
ExpressionWire, and twocontent/docs/corerows spell the pre-#7530 union #7725 — the same wire union still spelled inline incrud.ts/crud.zod.ts(ActionSchema.condition),select-option.ts,form.ts,objectql.ts; twocontent/docs/corerows andsemantic.mdx:63spell the pre-[Decision] The CEL envelope object is accepted onvisible/hidden/disabledby the shared evaluator but declared on none of them — declare it on all three, or refuse it on all three #7530 union.#7455 (landed as PR #7543) and #6938 are not addressed here. Refs: #7455 · #4581 · #4580 Q3-A · #7088. Session:
https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s.🤖 Generated with Claude Code
https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s
Generated by Claude Code